#!/usr/bin/env python
'''%prog [OPTIONS] [DIRECTORIES FILES]

Prepare a template based commit log.
Homepage: http://cvsxxdiff.googlecode.com/

WARNING: Verify the correctness of the actions this utility performs!
'''

# A companion program to cvsxxdiff, cvsnup
# ex: cvscommit `cvsnup -qs -M`
#
# version 0.2: Fork from cvsnup-1.04. Initial release to the public.
# version 0.2.1: Minor usability  imporvements.
# version 0.3: Now we can perform commits. To generate only the commit log, 
#	       use the -n option.
# version 0.4: Proper resolution of Locally Added files


__author__ = 'Michalis Giannakidis <mgiannakidis@gmail.com>'
__version__ = '0.1.5'


import os
import sys
import re
import optparse
#import textwrap
import tempfile


DoCommit = False

# Default template log:
Template_Log = '''BUG_FIX:
NEW_FEATURE:
FEATURE_CHANGE:
Mantis: ID ( http://localhost/mantis/view.php?id=ID )
DEVELOPERS:
<keep any of the above tags. Your commit message goes here...>

--
'''

#-------------------------------------------------------------------------------
#
def cprint(field, color = 'white'):
	"""Return the 'field' in collored terminal form"""

	Term_colors = {
		'black':30,
		'red':31,
		'green':32,
		'yellow':33,
		'blue':34,
		'magenta':35,
		'cyan':36,
		'white':37, 
	}
	field = '[01;%dm%s[00m' % ( Term_colors[color], str(field) )
	return field

#-------------------------------------------------------------------------------
#
def remove_files(files):
	"""Remove files, if they exist"""

	map(os.unlink, filter(os.path.isfile, files))

#-------------------------------------------------------------------------------
#
def get_cvsroot_path(dir):
	"""
	Extract the path from the CVS/Root file
	and the module name from CVS/Repository
	"""

	crt, crp = None, None
	if not dir:
		dir = ''

	cvsroot = open( os.path.join(dir, 'CVS', 'Root') ).readline()
	cvsroot = cvsroot.rstrip()
	if cvsroot[-1] == os.path.sep: cvsroot = cvsroot[:-1]

	i = 0
	while cvsroot[i]:
		if cvsroot[i] == os.path.sep:
			crt =  cvsroot[i:]
			break
		i = i + 1

	cvsrepos = open(os.path.join(dir, 'CVS', 'Repository' ) ).readline()
	crp = cvsrepos.rstrip()

	return crt, crp


#-------------------------------------------------------------------------------
#
class Cvsfilestatus:
	def __init__(self):
		self.short_filename = ''
		self.status = ''
		self.filename = ''
		self.rep_revision = ''
		self.work_revision = ''
	def __repr__(self):
		return ' '.join( (self.short_filename, self.status, \
			self.filename, self.rep_revision, self.work_revision) )

#-------------------------------------------------------------------------------
#
def parse_cvs_status(stat_filesdirs):

	files = []

	non_existent_files = ''
	for fd in stat_filesdirs:
		if not os.path.exists(fd):
			non_existent_files = non_existent_files +  fd

	if len(non_existent_files):
		print 'Cannot handle: %s' % ( non_existent_files )
		raise SystemExit('Aborting!')


	try:
		CvsRoot, CvsRepository = get_cvsroot_path(None)
	except IOError:
		raise SystemExit('Error: You are not in a CVS managed directory.\n\
See cvscommit -h for details')


	# transform a file 'Repository revision' to a local filename:
	# eg: /mnt/raid_disk/xxxxxx_cvsroot/xxxxxx/yyyyyyy/zzzzzzz.c,v -> yyyyyyy/zzzzzzz.c
	abs_file_prefix = os.path.sep.join([CvsRoot, CvsRepository, ''])

	match_status = re.compile('File:\s*([^\s]+)\s*Status: (.*)')
	match_status_nf = re.compile('File: no file\s*([^\s]+)\s*Status: (.*)')
	match_repos = re.compile( 
				'Repository revision:\s([\d\.]*)\s*'+abs_file_prefix+'(.*),v'
							 )
	match_work = re.compile('Working revision:\s([\d\.]*)')


	filesdirs = ' '.join(map(str, stat_filesdirs))
	sin, sout, serr = os.popen3('cvs -q status '+filesdirs)
	for l in sout:
	# match Status:, then Repository revision
		m = match_status.search(l) or match_status_nf.search(l)
		if m:
			cvsfilestat = Cvsfilestatus()
			files.append(cvsfilestat)

			cvsfilestat.short_filename = m.group(1)
			cvsfilestat.filename = cvsfilestat.short_filename
			cvsfilestat.status = m.group(2)
	
		mr = match_repos.search(l)
		if mr:

			cvsfilestat.filename = mr.group(2).strip()
			cvsfilestat.rep_revision = mr.group(1).strip()
			continue

		mw = match_work.search(l)
		if mw:
			cvsfilestat.work_revision = mw.group(1).strip()
			continue

	map(sys.stderr.write, serr)

	sin.close()
	sout.close()
	serr.close()


	files_len = len(files)
	files = filter(lambda x: x.status != 'Locally Added', files)
	if files_len != len(files):
		# we need to do extra work to determine locally added files.
		match_file = re.compile('A (.*)')
		sin, sout, serr = os.popen3('cvs -q -n update '+filesdirs)
		for l in sout:
			# match: A filename
			m = match_file.search(l)
			if m:
				cvsfilestat = Cvsfilestatus()
				files.append(cvsfilestat)

				cvsfilestat.short_filename = m.group(1)
				cvsfilestat.filename = m.group(1)
				cvsfilestat.status = 'Locally Added'

		map(sys.stderr.write, serr)

		sin.close()
		sout.close()
		serr.close()

	return files


#-------------------------------------------------------------------------------
#
def collect_unupdated_files( stat_filesdirs, status_intrested_in ):
	"""
	Collect all files that are not updated (compared to the repository).
	"""
	assert stat_filesdirs

	files = parse_cvs_status(stat_filesdirs)

	collected_files = {}
	for entry in files:
		if len(status_intrested_in):
			if not entry.status in status_intrested_in:
				continue
		else:
			if entry.status == 'Up-to-date':
				continue

		collected_files.setdefault(entry.status, []).append(entry)

	return collected_files


#-------------------------------------------------------------------------------
#
def get_revision_before(rev, back):
	"""Do math arithmetics to get a revision as 'back' versions before"""

	r = rev.split('.')

	r[-1] = str(int(r[-1]) - back)

	# we have reached the start of revision history... 
	# (propably a branch start)
	lr = int(r[-1])
	if lr <= 0:
		r = r[0:-2]
		if lr < 0:
			r[-1] = str(int(r[-1]) + lr)

	r = '.'.join(r)

	return r


#-------------------------------------------------------------------------------
#
def print_commit_log(collected_files):

	commit_error = False

	entries = []
	for e in collected_files.values():
		entries.extend(e)

	if not len(entries):
		return

	commit_files = []

	logs = {}
	for e in entries:
		if e.status == 'Locally Modified':
			assert ( e.work_revision == e.rep_revision )

			future_rev = get_revision_before(e.work_revision, -1)
			revision = '-r%s -r%s %s' % (e.work_revision, future_rev, e.filename)
			logs.setdefault('Modified', []).append(revision)
			commit_files.append(e.filename)
		elif e.status == 'Locally Added':
			# FIXME mgiann:
			# find the full path to e.filename
			revision = '%s' % (e.filename)
			logs.setdefault('Added', []).append(revision)
			commit_files.append(e.filename)
		elif e.status == 'Locally Removed':
			revision = '-r%s %s' % (e.rep_revision, e.filename)
			logs.setdefault('Removed', []).append(revision)
			commit_files.append(e.filename)
		else:
			print 'Don\'t know how to handle: %s, %s' % (e.filename, e.status)
			commit_error = True

	commit_files_str = ' '.join(map(str, commit_files))

	if commit_error:
		print cprint('Please verify that all files were processed.', 'red')
		return


	files_message = ''
	for status in logs:
		files = logs[status]
		msg = ''
		msg = msg + '%s (%d): ' % (status, len(files))
		msg = msg + ', '.join(map(str, files))
#		msg = textwrap.fill(msg, width=80) + '\n\n'
		msg = msg + '\n\n'
		files_message = files_message + msg


	cvs_log_file = tempfile.mktemp(prefix='cvscommit_')

	fd_cvs_log_file = file(cvs_log_file, 'w')
	fd_cvs_log_file.write(Template_Log)
	fd_cvs_log_file.write(files_message)
	fd_cvs_log_file.close()

	os.system('%s %s' % ( os.getenv('EDITOR', 'vi'), cvs_log_file))

	fd_cvs_log_file = file(cvs_log_file, 'r')
	for l in fd_cvs_log_file.readlines():
		sys.stdout.write(l)
	fd_cvs_log_file.close()

	print cprint('cvs commit %s' % (commit_files_str), 'cyan')

	if DoCommit == True:
		try:
			ret = raw_input('Commit? (y/n) ')
		except EOFError:
			ret = 'n'
		
		if ret in ( 'y', 'Y', 'yes' 'YES' ):
			sin, sout_serr = os.popen4( 'cvs commit -F %s %s' % (cvs_log_file, commit_files_str) )

			map(sys.stderr.write, sout_serr)

			sin.close()
			sout_serr.close()
		else:
			print cprint('Canceled!', 'red')

	remove_files( [cvs_log_file] )


#-------------------------------------------------------------------------------
# 
# From cvs documentation:
# Up-to-date
#      The file is identical with the latest revision in the repository
#      for the branch in use.
# 
# Locally Modified
#      You have edited the file, and not yet committed your changes.
# 
# Locally Added
#      You have added the file with `add', and not yet committed your
#      changes.
# 
# Locally Removed
#      You have removed the file with `remove', and not yet committed
#      your changes.
# 
# Needs Checkout
#      Someone else has committed a newer revision to the repository.
#      The name is slightly misleading; you will ordinarily use `update'
#      rather than `checkout' to get that newer revision.
# 
# Needs Patch
#      Like Needs Checkout, but the CVS server will send a patch rather
#      than the entire file.  Sending a patch or sending an entire file
#      accomplishes the same thing.
# 
# Needs Merge
#      Someone else has committed a newer revision to the repository, and
#      you have also made modifications to the file.
# 
# Unresolved Conflict
#      A file with the same name as this new file has been added to the
#      repository from a second workspace.  This file will need to be
#      moved out of the way to allow an `update' to complete.
# 
# File had conflicts on merge
#      This is like Locally Modified, except that a previous `update'
#      command gave a conflict.  If you have not already done so, you
#      need to resolve the conflict as described in *Note Conflicts
#      example::.
# 
# Unknown
#      CVS doesn't know anything about this file.  For example, you have
#      created a new file and have not run `add'.
# 

#-------------------------------------------------------------------------------
# 
def parse_options():
	parser = optparse.OptionParser(usage=__doc__.strip(),
	         version='%prog '+__version__)

	parser.add_option('-n', '--just-print', action='store_true',
	    dest='just_print',
	    help='print the resulting log message, but don\'t commit anything')

	parser.add_option('-t', '--template', action='store',
	    dest='template_file',
	    help='read template log from file instead of using the default')

	opts, args = parser.parse_args()

	global Template_Log

	if opts.template_file:
		if os.path.isfile(opts.template_file):
			fp = file(opts.template_file, 'r')
			Template_Log = ''.join(map(str, fp.readlines()))
			fp.close()
		else:
			parser.error('Invalid template file.')

	global DoCommit

	DoCommit = not opts.just_print

	return args

#-------------------------------------------------------------------------------
# 
def _cli():
	
	stat_filesdirs = parse_options()

	intrested_in = ['Locally Modified', 'Locally Added', 'Locally Removed']

	collected_files = collect_unupdated_files(stat_filesdirs or ['.'],
	    intrested_in)

	print_commit_log(collected_files)


#-------------------------------------------------------------------------------
# 
if __name__ == '__main__':  _cli()
